技术改变世界,文化改变人心

MetInfo SQL注入漏洞详解

MetInfo SQL注入漏洞详解

前言

之前CNVD爆出了一个最新版本的MetInfoSQL注入漏洞,根据payload正向分析了一遍漏洞原理,写一篇详细一点的

参数回溯

payload: admin/index.php?m=web&n=message&c=message&a=domessage&action=add&lang=cn&para137=1&para186=1&para138=1&para139=1&para140=1&id=42 and 1=1

需要在管理员页面触发,为一个bool类型的SQL盲注

问题出现在/app/message/web/message.class.php中的add函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//message类
public function add($info) {
global $_M;
if(!$_M[form][id]){
$message=DB::get_one("select * from {$_M[table][column]} where module= 7 and lang ='{$_M[form][lang]}'");
$_M[form][id]=$message[id];
}
$met_fd_ok=DB::get_one("select * from {$_M[table][config]} where lang ='{$_M[form][lang]}' and name= 'met_fd_ok' and columnid = {$_M[form][id]}");
$_M[config][met_fd_ok]= $met_fd_ok[value];
if(!$_M[config][met_fd_ok])okinfo('javascript:history.back();',"{$_M[word][Feedback5]}");
if($_M[config][met_memberlogin_code]){
if(!load::sys_class('pin', 'new')->check_pin($_M['form']['code'])){

okinfo(-1, $_M['word']['membercode']);
}
}

可以看到在第七行处的$_M[form][id]并没有被单引号包裹,出现了SQL注入漏洞,我们再回溯变量看看$_M[form][id]是从哪里来的,可以看到第二行定义了全局变量$_M

1
2
3
4
5
6
//message类
public function __construct() {
global $_M;
parent::__construct();
$this->upfile = load::sys_class('upfile', 'new');
}

并且在message的构造方法中也定义了$_M,且未初始化,判断$_M在message的构造方法中被赋值,在构造方法中还调用了父类的__construct方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//web类
public function __construct() {
parent::__construct();
global $_M;
// 可视化窗口语言栏跳转后,整个可视化页面跳转到新语言
if(strpos($_SERVER['HTTP_REFERER'], 'pageset=1')!==false && strpos($_SERVER['HTTP_REFERER'], 'lang=')!==false && strpos($_SERVER['HTTP_REFERER'], $_M['url']['site'])!==false){
preg_match('/lang=(\w+)/', $_SERVER['HTTP_REFERER'], $prev_lang);
if($prev_lang && $prev_lang[1] !=$_M['lang']){
$new_url="{$_M['url']['site_admin']}index.php?lang={$_M['lang']}&n=ui_set&pageset=1";
echo "<script>
parent.document.getElementsByClassName('page-iframe')[0].setAttribute('data-dynamic','{$_M['url']['site']}index.php?lang={$_M['lang']}');
parent.window.location.href='{$new_url}';
</script>";
die;
}
}

在web类的构造方法中并没有发现对$_M的赋值操作,而且web类又调用了父类的构造方法,继续回溯web类父类的构造方法

1
2
3
4
5
6
7
8
9
10
11
12
//common类
public function __construct() {
global $_M;//全局数组$_M
ob_start();//开启缓存
$this->load_mysql();//数据库连接
$this->load_form();//表单过滤
$this->load_lang();//加载语言配置
$this->load_config_global();//加载全站配置数据
$this->load_url_site();
$this->load_config_lang();//加载当前语言配置数据
$this->load_url();//加载url数据
}

在这里的load_form函数中对传入的GPC参数进行了过滤和赋值,并且将处理过的GPC转存到$_M

至此,我们找到了$_M赋值的地方,此时类的继承关系为:common->web->message

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
protected function load_form() {
global $_M;
$_M['form'] =array();
isset($_REQUEST['GLOBALS']) && exit('Access Error');
foreach($_COOKIE as $_key => $_value) {
$_key{0} != '_' && $_M['form'][$_key] = daddslashes($_value);
}
foreach($_POST as $_key => $_value) {
$_key{0} != '_' && $_M['form'][$_key] = daddslashes($_value);
}
foreach($_GET as $_key => $_value) {
$_key{0} != '_' && $_M['form'][$_key] = daddslashes($_value);
}
if(is_numeric($_M['form']['lang'])){//伪静态兼容
$_M['form']['page'] = $_M['form']['lang'];
$_M['form']['lang'] = '';
}
if($_M['form']['metid'] == 'list'){
$_M['form']['list'] = 1;
$_M['form']['metid'] = $_M['form']['page'];
$_M['form']['page'] = 1;
}
if(!preg_match('/^[0-9A-Za-z]+$/', $_M['form']['lang']) && $_M['form']['lang']){
echo "No data in the database,please reinstall.";
die();
}
}

load_form函数中我们发现了赋值给$_M的过程和过滤函数daddslashes,跟入查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 对字符串进行反斜杠处理,如果服务器开启MAGIC_QUOTES_GPC。则不处理。
* @param string/array $string 处理的字符串或数组
* @param bool $force 是否强制反斜杠处理
* @return array 返回处理好的字符串或数组
*/
function daddslashes($string, $force = 0) {
!defined('MAGIC_QUOTES_GPC') && define('MAGIC_QUOTES_GPC', get_magic_quotes_gpc());
if(!MAGIC_QUOTES_GPC || $force) {
if(is_array($string)) {
foreach($string as $key => $val) {
$string[$key] = daddslashes($val, $force);
}
} else {
if(!defined('IN_ADMIN')){
$string = trim(addslashes(sqlinsert($string)));
}else{
$string = trim(addslashes($string));
}
}
}
return $string;
}

在没有定义IN_ADMIN的时候在addslashes之前还调用了sqlinsert函数,这也就是为什么作者说在第一次尝试注入的时候发现SQL关键词都被清除了,所以我们需要找到一个地方定义了IN_ADMIN来绕过sqlinsert函数

正向分析

即然我们需要IN_ADMIN值不为false,根据字面意思判断,就可以知道需要在管理页面找,而admin/index.php正好定义了IN_ADMIN的值

而且在此文件中我们可以更改GET参数来调用各种模型,类名,操作名(操作名必须以do开头)

作者找到了domessage函数可以触发add方法,并且将GET的参数全部传递过去,成功注入,鉴于里面的逻辑太复杂,直接使用xdebug跟踪函数调用

输入payload之后直接跟到调用add函数的位置,可以在PHPstorm中看到所有的函数调用关系

_load_class中实例化了message类

在实例化的时候调用message类的构造方法,调用了message父类web的父类common的构造方法,并且使用了load_form过滤了传入的GPC参数,这个地方在跟踪的时候发现居然跳到了web类中实现的load_form函数,刚开始有点迷,后来想清楚了

在学面向对象的时候继承是一个很重要的概念,而这个时候就是面向对象的一个特性,此时调用方法是:$this->load_form();//表单过滤,可以看到当前的\$this指向的是message类,这个时候去message类找load_form()函数,发现没有这个函数,根据继承的特性,程序向message类的父类去找load_form()函数,发现它的父类实现了load_form,所以直接调用message父类的load_form,而这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 重写common类的load_form方法,前台对提交的GET,POST,COOKIE进行安全的过滤处理
*/
protected function load_form() {
global $_M;
parent::load_form();
foreach ($_M['form'] as $key => $val) {
$_M['form'][$key] = sqlinsert($val);
}
if ($_M['form']['id']!='' && !is_numeric($_M['form']['id'])) {
$_M['form']['id'] = '';
}
if ($_M['form']['class1']!='' && !is_numeric($_M['form']['class1'])) {
$_M['form']['class1'] = '';
}
if ($_M['form']['class2']!='' && !is_numeric($_M['form']['class2'])) {
$_M['form']['class2'] = '';
}
if ($_M['form']['class3']!='' && !is_numeric($_M['form']['class3'])) {
$_M['form']['class3'] = '';
}
}

可以看到这里对每一个table传入的参数进行一次sqlinsert过滤,按理来说我们的payload已经被过滤了,通过监视$_M,确实我们的payload已经为空了

但是为什么我们的payload最后还能执行呢,再跟踪函数,我发现在调用完web类的load_form函数之后,调用了$this->upfile = load::sys_class('upfile', 'new');,在这个函数中又重新调用了common的构造方法,结果把payload又赋值给了$_M

这个时候我们的payload又回到了$_M的id参数中

这个时候,可以确定payload可以拼接入SQL语句中,但是接下来还需要将所有逻辑走通,接下来解答几个问题

为什么需要paraXXX参数

在执行domessage函数的时候,可以看到在执行add函数之前执行了一个check_field函数,我们进入查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function check_field(){
global $_M;
$messagecfg= load::mod_class('message/message_handle','new')->get_message_config(load::mod_class('message/message_database','new')->get_message_columnid());
$met_message_fd_class=$_M[form]['para'.$messagecfg[met_message_fd_class][value]];
$met_message_fd_content=$_M[form]['para'.$messagecfg[met_message_fd_content][value]];
$met_message_fd_email=$_M[form]['para'.$messagecfg[met_message_fd_email][value]];
$met_message_fd_sms=$_M[form]['para'.$messagecfg[met_message_fd_sms][value]];
$met_fd_back=$messagecfg[met_fd_back][value];
$paralist=load::mod_class('parameter/parameter_database','new')->get_parameter('7');
foreach ($paralist as $key => $val) {
$para[$val[id]]=$val;
}

$paraarr = array();
foreach (array_keys($_M['form']) as $vale) {
if (strstr($vale, 'para')) {
if (strstr($vale, '_')) {
$arr = explode('_',$vale);
$paraarr[] = str_replace('para','',$arr[0]);
}else{
$paraarr[] = str_replace('para','',$vale);
}
}
}

foreach (array_keys($para) as $val) {
if($para[$val]['wr_ok']==1 && !in_array($val,$paraarr)){
$info="【{$para[$val]['name']}】".$_M[word][noempty];
okinfo('javascript:history.back();',$info);
}
}
//met_message_fd_class 姓名
//met_message_fd_content 留言内容
//met_message_fd_email 邮箱
// met_message_fd_sms 电话
}

下面这些变量就是我们传入参数中paraXXX的内容:

1
2
3
4
5
$met_message_fd_class=$_M[form]['para'.$messagecfg[met_message_fd_class][value]];
$met_message_fd_content=$_M[form]['para'.$messagecfg[met_message_fd_content][value]];
$met_message_fd_email=$_M[form]['para'.$messagecfg[met_message_fd_email][value]];
$met_message_fd_sms=$_M[form]['para'.$messagecfg[met_message_fd_sms][value]];
$met_fd_back=$messagecfg[met_fd_back][value];

这一行if($para[$val]['wr_ok']==1 && !in_array($val,$paraarr))判断了我们传入的参数中有无paraXXX


我们需要在传入的参数中有137,186,138,139,140,否则会进入if条件中的逻辑

1
2
$info="【{$para[$val]['name']}】".$_M[word][noempty];
okinfo('javascript:history.back();',$info);

为什么id必须为42

1
2
3
$met_fd_ok=DB::get_one("select * from {$_M[table][config]} where lang ='{$_M[form][lang]}' and  name= 'met_fd_ok' and columnid = {$_M[form][id]}");
$_M[config][met_fd_ok]= $met_fd_ok[value];
if(!$_M[config][met_fd_ok])okinfo('javascript:history.back();',"{$_M[word][Feedback5]}");

可以看到,从数据库中取出$_met_fd_ok的值,如果$_met_fd_ok[value]不为空的话,继续往下执行,我们进入数据库看看这条语句到底取出来的是哪些值

可以看到我们想让它的返回不为空,id的值必须为42或者44,所以我们的注入的时候必须保证id的值为44或者42

总结

metinfo的SQL注入漏洞是作者在几次给metinfo提交后met官方不予理睬才曝光的,希望厂商能多多重视安全吧